Skip to content

Automated Test: implement-device-limits #344

Closed

Conversation

admin-coderabbit
Copy link
Owner

@admin-coderabbit admin-coderabbit commented Feb 4, 2026

This pull request was automatically created by @coderabbitai/e2e-reviewer.

Batch created pull request.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added configuration option to limit the number of anonymous devices that can be registered
    • Device creation now respects the configured limit and prevents registration of additional devices when the limit is reached
    • Anonymous device limit setting is now exposed through API endpoints for frontend visibility

* Anonymous: Add device limiter

* break auth if limit reached

* fix typo

* refactored const to make it clearer with expiration

* anon device limit for config

---------

Co-authored-by: Eric Leijonmarck <eric.leijonmarck@gmail.com>
@coderabbit-eval
Copy link

coderabbit-eval bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

This PR introduces anonymous device limit enforcement across Grafana by adding configuration properties to TypeScript and Go backends, implementing per-device limit logic in the anonymous device store with a new error type, updating frontend settings to expose the configured limit, and refactoring service dependency injection to initialize the store with the configured device limit.

Changes

Cohort / File(s) Summary
TypeScript Configuration
packages/grafana-data/src/types/config.ts, packages/grafana-runtime/src/config.ts
Added anonymousDeviceLimit property (type number | undefined) to GrafanaConfig and GrafanaBootConfig interfaces.
Backend DTOs & Frontend Settings
pkg/api/dtos/frontend_settings.go, pkg/api/frontendsettings.go
Added AnonymousDeviceLimit field to FrontendSettingsDTO struct and populated it in frontend settings response construction.
Anonymous Device Store Core
pkg/services/anonymous/anonimpl/anonstore/database.go, pkg/services/anonymous/anonimpl/anonstore/database_test.go
Introduced device expiration window constant, new ErrDeviceLimitReached error, device limit field to AnonDBStore, private updateDevice helper method, and device limit enforcement logic in CreateOrUpdateDevice. Updated constructor signature and added test case validating limit enforcement.
Service Layer & API
pkg/services/anonymous/anonimpl/api/api.go, pkg/services/anonymous/anonimpl/client.go, pkg/services/anonymous/anonimpl/impl.go, pkg/services/anonymous/anonimpl/impl_test.go
Refactored dependency injection to pass raw DB store instead of pre-constructed store, updated TagDevice error handling to propagate device limit errors, replaced timeoutTag constant with direct synchronous calls, and adjusted test fixtures to match new wiring.
Configuration Loading
pkg/setting/setting.go
Added AnonymousDeviceLimit field to Cfg struct and updated readAuthSettings to read the device_limit value from auth.anonymous config section.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A limit on devices, how wise and how neat!
We count them, update them, make logic complete.
When too many gather, an error we send—
The anonymous devices can now find an end! 🎪

🚥 Pre-merge checks | ✅ 1 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Title check ⚠️ Warning The title 'Automated Test: implement-device-limits' does not accurately reflect the primary changes in the pull request, which implement device limit enforcement functionality across multiple layers (frontend, backend, database, API) rather than automated tests. Revise the title to reflect the main change, such as 'Implement anonymous device limit enforcement' or 'Add device limit configuration and enforcement logic'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch implement-device-limits

Comment @coderabbit eval help to get the list of available commands and usage tips.

@coderabbit-eval coderabbit-eval bot closed this Feb 4, 2026
Copy link

@coderabbit-eval coderabbit-eval bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/services/anonymous/anonimpl/impl.go (1)

100-115: ⚠️ Potential issue | 🟡 Minor

Pre-existing bug: untagDevice logs the wrong error variable.

Line 113 logs err (the function parameter, which is nil at this point due to the early return on line 102-104) instead of errD (the actual deletion error). While not introduced in this PR, it's adjacent to the changed code and worth fixing.

🐛 Proposed fix
 	errD := a.anonStore.DeleteDevice(ctx, deviceID)
 	if errD != nil {
-		a.log.Debug("Failed to untag device", "error", err)
+		a.log.Debug("Failed to untag device", "error", errD)
 	}
🤖 Fix all issues with AI agents
In `@pkg/api/dtos/frontend_settings.go`:
- Line 195: Change the DTO field AnonymousDeviceLimit from a non-pointer int64
to a pointer (*int64) and add the `omitempty` json tag so it serializes as
absent when unset (matches the TS type `number | undefined`); update any
constructors/initializers or places that populate the struct (e.g., where
FrontendSettings or its factory is created) to set nil when there is no
configured limit and to take the address of an int64 value when you need to
provide one, and audit any consumers (serialization, validation, defaults) of
AnonymousDeviceLimit to handle a nil pointer appropriately.

In `@pkg/services/anonymous/anonimpl/anonstore/database.go`:
- Around line 105-118: The CountDevices check in CreateOrUpdateDevice is a
TOCTOU race: CountDevices and the subsequent insert/update (via updateDevice)
run in separate DB sessions so concurrent callers can exceed deviceLimit; fix by
making the check-and-insert atomic—either perform the count and insert/update
inside a single database transaction in CreateOrUpdateDevice (so use the same DB
transaction/session and rollback on conflict) or enforce the limit with a
DB-level constraint (unique/conditional constraint or a counter row) and handle
the constraint error in CreateOrUpdateDevice; if you intentionally want a
best-effort limit, add a clear comment in CreateOrUpdateDevice mentioning that
deviceLimit is advisory and not strictly enforced.

In `@pkg/services/anonymous/anonimpl/client.go`:
- Around line 44-50: Synchronous call to anonDeviceService.TagDevice on the
request path (in anonimpl client code) causes per-request DB latency; either
validate this is acceptable under expected load or implement a short-circuit
cache/in-memory counter to avoid calling TagDevice for the common “well below
limit” case and only invoke TagDevice when the in-memory count nears the limit;
specifically add an in-memory device-count cache keyed by anonymous identity
(with TTL and atomic increments/decrements) used by the authentication flow to
skip TagDevice when safely below threshold, and fall back to calling
anonDeviceService.TagDevice (and honoring anonstore.ErrDeviceLimitReached) when
cache indicates a possible limit breach.
🧹 Nitpick comments (4)
packages/grafana-runtime/src/config.ts (1)

97-97: Missing explicit type annotation — inferred type is undefined instead of number | undefined.

Without an explicit annotation, TypeScript infers this property as type undefined, so code accessing config.anonymousDeviceLimit via a GrafanaBootConfig reference won't see number as a valid type. This is consistent with other fields in this class (e.g., googleAnalyticsId, rudderstackWriteKey), so it's a pre-existing pattern, but worth fixing here for type safety.

Suggested fix
-  anonymousDeviceLimit = undefined;
+  anonymousDeviceLimit: number | undefined = undefined;
pkg/setting/setting.go (1)

1655-1655: Consider adding validation for device_limit to document expected behavior.

While the code safely handles negative values (treating them as 0 to disable the limit via the if s.deviceLimit > 0 check), adding explicit validation at config load time would clarify intent. Optional, but defensible.

Suggested fix
 	cfg.AnonymousDeviceLimit = anonSection.Key("device_limit").MustInt64(0)
+	if cfg.AnonymousDeviceLimit < 0 {
+		cfg.AnonymousDeviceLimit = 0
+	}
pkg/services/anonymous/anonimpl/anonstore/database.go (1)

18-18: Use errors.New instead of fmt.Errorf for sentinel errors.

ErrDeviceLimitReached has no format arguments, so errors.New is the idiomatic choice and avoids unnecessary formatting overhead.

♻️ Proposed fix
+import "errors"
+
-var ErrDeviceLimitReached = fmt.Errorf("device limit reached")
+var ErrDeviceLimitReached = errors.New("device limit reached")
pkg/services/anonymous/anonimpl/anonstore/database_test.go (1)

51-70: Test covers the basic limit enforcement path well.

Consider adding a complementary test case that verifies an existing device (same DeviceID) can still be updated when the limit is reached. This would exercise the updateDevice happy path where rowsAffected > 0.

@@ -192,6 +192,7 @@ type FrontendSettingsDTO struct {

FeatureToggles map[string]bool `json:"featureToggles"`
AnonymousEnabled bool `json:"anonymousEnabled"`
AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"`

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how anonymousDeviceLimit is consumed on the frontend side
rg -n --type=ts --type=tsx -C3 'anonymousDeviceLimit' -g '!node_modules'

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 113


🏁 Script executed:

#!/bin/bash
# Check how the value is set on the Go side to understand the semantics of 0
rg -n -C3 'AnonymousDeviceLimit' --type=go

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 2989


🏁 Script executed:

#!/bin/bash
# Search for anonymousDeviceLimit in TypeScript/JavaScript files
rg -n 'anonymousDeviceLimit' --type-add 'ts:*.{ts,tsx}' --type ts

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 253


🏁 Script executed:

#!/bin/bash
# Also check if there's documentation or comments about what 0 means for device limit
rg -n -B3 -A3 'device_limit' --type=go

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 624


🏁 Script executed:

#!/bin/bash
# Find how anonymousDeviceLimit is used in frontend logic
rg -n 'anonymousDeviceLimit' -A5 -B2 --type=ts | head -100

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 1354


🏁 Script executed:

#!/bin/bash
# Check if there's any documentation about what the device limit value means
rg -n -B5 -A5 'device.limit|device_limit' --type=go | head -80

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 2779


🏁 Script executed:

#!/bin/bash
# Find all usages of anonymousDeviceLimit in the frontend
rg -n 'anonymousDeviceLimit' -B3 -A10 packages/

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 2258


🏁 Script executed:

#!/bin/bash
# Check the anonstore logic more carefully to confirm the semantics of deviceLimit
rg -n 'deviceLimit' pkg/services/anonymous/anonimpl/anonstore/database.go -B2 -A2

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 699


🏁 Script executed:

#!/bin/bash
# Broader search for any reference to anonymousDeviceLimit
git -C . grep -n "anonymousDeviceLimit" -- "packages/" "public/" "src/" 2>/dev/null | grep -v "\.map:" || echo "No matches found"

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 253


🏁 Script executed:

#!/bin/bash
# Check if there are any computed properties or indirect references using the config object
rg -n "config\.anon|getConfig\(\)\.anon" --type=ts

Repository: admin-coderabbit/coderabbit-grafana-mirror

Length of output: 219


Consider using *int64 with omitempty to align with the TypeScript type.

The Go DTO field AnonymousDeviceLimit is int64 without omitempty, which means it always serializes to JSON (as 0 when unconfigured). However, the TypeScript interface declares anonymousDeviceLimit: number | undefined, and the frontend config defaults to undefined. This creates a minor type inconsistency: when no limit is configured, the backend sends {"anonymousDeviceLimit": 0}, which deserializes to 0 rather than undefined.

While the field is currently unused on the frontend, using *int64 with omitempty would clarify intent and prevent issues if the value is consumed later:

Suggested fix
-	AnonymousDeviceLimit             int64                          `json:"anonymousDeviceLimit"`
+	AnonymousDeviceLimit             *int64                         `json:"anonymousDeviceLimit,omitempty"`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"`
AnonymousDeviceLimit *int64 `json:"anonymousDeviceLimit,omitempty"`
🤖 Prompt for AI Agents
In `@pkg/api/dtos/frontend_settings.go` at line 195, Change the DTO field
AnonymousDeviceLimit from a non-pointer int64 to a pointer (*int64) and add the
`omitempty` json tag so it serializes as absent when unset (matches the TS type
`number | undefined`); update any constructors/initializers or places that
populate the struct (e.g., where FrontendSettings or its factory is created) to
set nil when there is no configured limit and to take the address of an int64
value when you need to provide one, and audit any consumers (serialization,
validation, defaults) of AnonymousDeviceLimit to handle a nil pointer
appropriately.

Comment on lines 105 to +118
func (s *AnonDBStore) CreateOrUpdateDevice(ctx context.Context, device *Device) error {
var query string

// if device limit is reached, only update devices
if s.deviceLimit > 0 {
count, err := s.CountDevices(ctx, time.Now().UTC().Add(-anonymousDeviceExpiration), time.Now().UTC().Add(time.Minute))
if err != nil {
return err
}

if count >= s.deviceLimit {
return s.updateDevice(ctx, device)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

TOCTOU race: count check + insert are not atomic.

CountDevices and the subsequent insert/update run in separate DB sessions. Under concurrent requests, multiple goroutines can pass the count check simultaneously and all proceed to insert, exceeding the configured device limit.

If strict enforcement is required, consider wrapping the count + insert in a single transaction, or using a database-level constraint. If this is intended as a soft/best-effort limit, a brief comment documenting that would help future readers.

🤖 Prompt for AI Agents
In `@pkg/services/anonymous/anonimpl/anonstore/database.go` around lines 105 -
118, The CountDevices check in CreateOrUpdateDevice is a TOCTOU race:
CountDevices and the subsequent insert/update (via updateDevice) run in separate
DB sessions so concurrent callers can exceed deviceLimit; fix by making the
check-and-insert atomic—either perform the count and insert/update inside a
single database transaction in CreateOrUpdateDevice (so use the same DB
transaction/session and rollback on conflict) or enforce the limit with a
DB-level constraint (unique/conditional constraint or a counter row) and handle
the constraint error in CreateOrUpdateDevice; if you intentionally want a
best-effort limit, add a clear comment in CreateOrUpdateDevice mentioning that
deviceLimit is advisory and not strictly enforced.

Comment on lines +44 to +50
if err := a.anonDeviceService.TagDevice(ctx, httpReqCopy, anonymous.AnonDeviceUI); err != nil {
if errors.Is(err, anonstore.ErrDeviceLimitReached) {
return nil, err
}
}()

a.log.Warn("Failed to tag anonymous session", "error", err)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Synchronous TagDevice adds latency to every anonymous request — verify this is acceptable.

Previously, TagDevice ran asynchronously in a goroutine with a timeout. Now it's synchronous on the request path, meaning every anonymous authentication blocks until the DB round-trip completes. This is necessary for correct device-limit enforcement (you need the result before deciding whether to allow the request), but it adds latency to all anonymous requests, not just those hitting the limit.

Consider whether a caching layer or in-memory check could short-circuit the common case (well below limit) to reduce per-request overhead, or whether the current approach is acceptable given expected traffic patterns.

The error handling logic itself is correct: ErrDeviceLimitReached blocks authentication while other errors are logged and authentication proceeds.

🤖 Prompt for AI Agents
In `@pkg/services/anonymous/anonimpl/client.go` around lines 44 - 50, Synchronous
call to anonDeviceService.TagDevice on the request path (in anonimpl client
code) causes per-request DB latency; either validate this is acceptable under
expected load or implement a short-circuit cache/in-memory counter to avoid
calling TagDevice for the common “well below limit” case and only invoke
TagDevice when the in-memory count nears the limit; specifically add an
in-memory device-count cache keyed by anonymous identity (with TTL and atomic
increments/decrements) used by the authentication flow to skip TagDevice when
safely below threshold, and fall back to calling anonDeviceService.TagDevice
(and honoring anonstore.ErrDeviceLimitReached) when cache indicates a possible
limit breach.

Sign in to join this conversation on GitHub.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant